Preserve gRPC channel recreation when externalized payloads are enabled - #797
Preserve gRPC channel recreation when externalized payloads are enabled#797wangbill (YunchuWang) wants to merge 2 commits into
Conversation
Enabling UseExternalizedPayloads (AzureBlobPayloads) silently disabled gRPC
channel recreation on both the worker and the client.
IConfigureOptions runs before IPostConfigureOptions. DTS's ConfigureGrpcChannel
sets both options.Channel and SetChannelRecreator(...). The AzureBlobPayloads
PostConfigure then moved the channel onto an intercepted CallInvoker and nulled
options.Channel (it had to, because "Channel supersedes CallInvoker" per
GrpcDurableTaskClientOptions). That killed every recreation path:
- Worker path 1 guard requires a non-null channel; latestObservedChannel is
seeded from grpcOptions.Channel == null.
- Worker path 2 requires Channel and CallInvoker both null; CallInvoker was set.
- Client's CallInvoker branch explicitly cannot recreate an external channel.
Recreation is on by default (ChannelRecreateFailureThreshold = 5), so on backend
scale/upgrade/node replacement the worker wedged on a half-open HTTP/2
connection and never recovered until the process was restarted.
Fix: add an internal CallInvokerDecorator hook, mirroring the existing
SetChannelRecreator idiom. The extension registers a decorator instead of
mutating Channel/CallInvoker, and core applies it at every point a CallInvoker
is produced - including after a channel recreate. The decorator is applied
outside ChannelRecreatingCallInvoker so internal channel swaps stay transparent
to the interceptor. Purely additive: with no decorator set, behavior is
unchanged.
Also fixes a second defect: UseGrpc("http://localhost:4001") (Address-only)
previously threw ArgumentException and was unusable with externalized payloads.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a shipped regression where enabling the AzureBlobPayloads externalized-payloads extension inadvertently disabled gRPC channel recreation for both the worker and client, preventing self-healing from wedged/half-open HTTP/2 connections. It does so by introducing an internal CallInvokerDecorator hook that lets extensions attach interceptors without mutating Channel/CallInvoker, and ensures the decorator is applied consistently (including after worker channel recreation).
Changes:
- Added internal
SetCallInvokerDecorator/ApplyCallInvokerDecoratorhooks for both worker and client gRPC options. - Updated worker and client invoker construction paths to apply the decorator in the correct place (client: outside
ChannelRecreatingCallInvoker; worker: on initial invoker and on recreated invokers). - Updated AzureBlobPayloads DI extensions to register a decorator (instead of nulling
Channel) and added comprehensive regression and core tests.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/Worker/Grpc.Tests/GrpcDurableTaskWorkerOptionsInternalTests.cs | Verifies safe defaults and contract behavior for the new worker CallInvokerDecorator option. |
| test/Worker/Grpc.Tests/GrpcDurableTaskWorkerCallInvokerDecoratorTests.cs | Ensures worker applies the decorator for initial invokers and both channel recreation paths. |
| test/Client/Grpc.Tests/GrpcDurableTaskClientCallInvokerDecoratorTests.cs | Ensures client applies the decorator across Channel/Address/external-invoker paths and outside ChannelRecreatingCallInvoker. |
| test/Extensions/AzureBlobPayloads.Tests/ExternalizedPayloadsCallInvokerDecoratorTests.cs | Regression tests proving externalized payloads no longer null Channel, no longer break Address-only setup, and still intercepts calls end-to-end. |
| test/Extensions/AzureBlobPayloads.Tests/AzureBlobPayloads.Tests.csproj | Adds DI package reference needed by new test coverage. |
| src/Worker/Grpc/GrpcDurableTaskWorkerOptions.cs | Introduces internal CallInvokerDecorator option storage for the worker. |
| src/Worker/Grpc/Internal/InternalOptionsExtensions.cs | Adds worker SetCallInvokerDecorator and ApplyCallInvokerDecorator internal APIs. |
| src/Worker/Grpc/GrpcDurableTaskWorker.cs | Applies the decorator when building invokers and when recreating channels. |
| src/Client/Grpc/GrpcDurableTaskClientOptions.cs | Introduces internal CallInvokerDecorator option storage for the client. |
| src/Client/Grpc/Internal/InternalOptionsExtensions.cs | Adds client SetCallInvokerDecorator and ApplyCallInvokerDecorator internal APIs. |
| src/Client/Grpc/GrpcDurableTaskClient.cs | Applies the decorator outside core invoker creation to preserve recreation semantics. |
| src/Extensions/AzureBlobPayloads/DependencyInjection/DurableTaskWorkerBuilderExtensions.AzureBlobPayloads.cs | Switches from mutating Channel/CallInvoker to registering a decorator and preserves LargePayloads capability. |
| src/Extensions/AzureBlobPayloads/DependencyInjection/DurableTaskClientBuilderExtensions.AzureBlobPayloads.cs | Switches client externalized payloads to decorator-based interception without breaking channel recreation. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
halspang
left a comment
There was a problem hiding this comment.
Overall it seems fine, but I'd like to find a different way to handle the internal nature of the options if we can.
| /// This is an internal API that supports the DurableTask infrastructure and not subject to | ||
| /// the same compatibility standards as public APIs. It may be changed or removed without notice in | ||
| /// any release. You should only use it directly in your code with extreme caution and knowing that | ||
| /// doing so can result in application failures when updating to a new DurableTask release. | ||
| /// </remarks> |
There was a problem hiding this comment.
This makes me nervous. Is there no way we can keep this internal?
| /// This is an internal API that supports the DurableTask infrastructure and not subject to | ||
| /// the same compatibility standards as public APIs. It may be changed or removed without notice in | ||
| /// any release. You should only use it directly in your code with extreme caution and knowing that | ||
| /// doing so can result in application failures when updating to a new DurableTask release. |
| /// This is an internal API that supports the DurableTask infrastructure and not subject to | ||
| /// the same compatibility standards as public APIs. It may be changed or removed without notice in | ||
| /// any release. You should only use it directly in your code with extreme caution and knowing that | ||
| /// doing so can result in application failures when updating to a new DurableTask release. |
There was a problem hiding this comment.
Same issue here, it feels weird to expose an internal option with a comment saying to not use it.
…ollection
Addresses review feedback that the previous revision exposed `public`
extension methods (`SetCallInvokerDecorator` / `ApplyCallInvokerDecorator`)
in a `.Internal` namespace whose only protection was an XML remark saying
"do not use".
`ApplyCallInvokerDecorator` was only ever called from the assembly that
declared it, so it never needed to be public at all. The one hook that does
need to cross an assembly boundary is now a first-class, supported public
extensibility point modeled on `Grpc.Net.ClientFactory`'s
`IHttpClientBuilder.AddInterceptor()`:
public IList<Grpc.Core.Interceptors.Interceptor> Interceptors { get; }
added to both `GrpcDurableTaskClientOptions` and
`GrpcDurableTaskWorkerOptions`. Net public API surface is smaller than the
previous revision (one property per options class instead of four extension
methods), and interceptors compose additively so multiple extensions can
coexist — something a single `Func<CallInvoker, CallInvoker>` could not do.
The functional fix is unchanged. Interceptors are applied at exactly the
same sites the decorator was:
- `GrpcDurableTaskClient.GetCallInvoker` — outside any
`ChannelRecreatingCallInvoker`, so the wrapper's internal channel swaps
stay transparent to interceptors.
- `GrpcDurableTaskWorker.GetCallInvoker`.
- Both `ChannelRecreateResult` construction sites in
`GrpcDurableTaskWorker.TryRecreateChannelAsync` (recreator-owned and
worker-owned paths), so interceptors survive every channel recreate.
The AzureBlobPayloads extension now calls `opt.Interceptors.Add(...)`
instead of mutating `Channel`/`CallInvoker`, so enabling
`UseExternalizedPayloads` no longer silently disables gRPC channel
recreation, and the `Address`-only form `UseGrpc("http://localhost:4001")`
no longer throws.
Interceptor ordering is list order (first added is outermost); this is
documented on both properties and pinned by a test.
Tests renamed from `*CallInvokerDecoratorTests` to `*InterceptorsTests` and
reworked to assert through the real invoker-building path rather than
calling the removed extension method directly. Added coverage for
interceptor ordering and for the purely-additive invariant (with an empty
`Interceptors` list the produced invoker is reference-identical to the
undecorated one) on both worker and client.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Summary
Enabling
UseExternalizedPayloads(the AzureBlobPayloads large-payload extension) silently disabled gRPC channel recreation on both the worker and the client. This is a shipped bug onmain, introduced by #468 (ae1433aa).User-visible impact
Channel recreation is on by default (
ChannelRecreateFailureThreshold = 5in bothGrpcDurableTaskWorkerOptionsandGrpcDurableTaskClientOptions). Its whole purpose, perSetChannelRecreator's own doc comment, is to recover from "repeated connect failures (e.g., because the backend was replaced and the existing channel is wedged on a half-open HTTP/2 connection)".With externalized payloads enabled, that recovery was gone. On backend scale-out, upgrade, or node replacement, the worker wedges on a half-open HTTP/2 connection, stops processing work, and never recovers until the process is manually restarted.
Root cause
IConfigureOptionsruns beforeIPostConfigureOptions.ConfigureGrpcChannel(anIConfigureOptions) sets bothoptions.Channel(DurableTaskSchedulerWorkerExtensions.cs:176) andoptions.SetChannelRecreator(...)(:185).PostConfigurethen ran, moved the channel onto an interceptedCallInvoker, and nulledoptions.Channel.It had to null
Channel, because the core contract documents that "Channel ... will supersede CallInvoker" (GrpcDurableTaskClientOptions.cs:17) — leavingChannelset would make core build a raw invoker and bypass the interceptor entirely.The consequence was that every recreation path died:
recreator is not null && currentChannel is not nulllatestObservedChannelis seeded fromgrpcOptions.Channel, which was nownullChannel is null && CallInvoker is nullCallInvokerwas now setCallInvokerbranchProbe output against the shipped code (DTS-shaped worker,
IConfigureOptionssetting Channel + recreator, then optionalUseExternalizedPayloads):Why the simpler alternatives don't work
Channel. Core then takes theChannelbranch and builds a raw invoker, so the interceptor is dropped at startup and large payloads break outright.CallInvoker. Directly contradicts the documented public contract atGrpcDurableTaskClientOptions.cs:17and changes behavior for every consumer, not just this extension.newChannel.CreateCallInvoker()with no extension hook, so the interceptor would be lost on every recreate anyway.The fix: an internal
CallInvokerDecoratorhookStop mutating
Channel/CallInvokerfrom the extension. Instead the extension registers a decorator, and core applies it at every point aCallInvokeris produced — including after a channel recreate.Channelstays set (so recreation works) and the interceptor is always applied (so large payloads work).This mirrors the existing
SetChannelRecreatoridiom exactly (naming, placement, accessibility, doc-comment style).GrpcDurableTaskClientOptions.InternalOptions/GrpcDurableTaskWorkerOptions.InternalOptions: newFunc<CallInvoker, CallInvoker>? CallInvokerDecorator.InternalOptionsExtensions: newSetCallInvokerDecorator(...)plus a publicApplyCallInvokerDecorator(options, invoker)helper (the extension is a separate assembly and cannot reachInternalOptionsdirectly).GrpcDurableTaskClient.GetCallInvoker→ renamed toGetCallInvokerCore, with a thinGetCallInvokerwrapper that applies the decorator once. The decorator is applied outsideChannelRecreatingCallInvoker, so the wrapper's internal channel swaps stay transparent to the interceptor.GrpcDurableTaskWorker.GetCallInvoker→ same rename + wrap.GrpcDurableTaskWorker.TryRecreateChannelAsyncapplies the decorator to the new invoker at both recreate sites. This is the crux — without it the interceptor would be silently lost on every recreate, which would be worse than the original bug.if (Channel) / else if (CallInvoker) / else throwblock is replaced with a singleSetCallInvokerDecorator(inv => inv.Intercept(new AzureBlobPayloadsSideCarInterceptor(store, opts))).Channelis no longer nulled. The worker still addsP.WorkerCapability.LargePayloads.Design invariant: purely additive to core. With no decorator set, behavior is byte-for-byte identical to today. This is asserted by tests on both the worker and the client.
Bonus bug fixed
Deleting the
else { throw }fixes a second, independent defect: todayUseGrpc("http://localhost:4001")— the Address-only form, the most common raw-gRPC worker setup — throwsArgumentExceptionand is completely unusable with externalized payloads. OnlyUseGrpc(o => o.CallInvoker = ...)worked. With the decorator, core creates its channel fromAddressand the decorator still applies. One change, two bugs.Tests
New coverage (all four suites green):
ExternalizedPayloadsCallInvokerDecoratorTests, 8 tests) — worker and clientChannelis no longer nulled; Address-only no longer throws; the external-CallInvokerpath is no longer mutated;LargePayloadscapability is still announced; and two end-to-end interception tests that drive a realCreateInstanceRequestthrough the decorated invoker and assert the payload was actually uploaded to the store and replaced with a token. (There was no pre-existing interception test.)GrpcDurableTaskWorkerCallInvokerDecoratorTests, 4 tests) — decorator applied to the initial invoker; not applied when unset; and applied to the invoker produced after a channel recreate on both recreate paths (reached via reflection onTryRecreateChannelAsync).GrpcDurableTaskClientCallInvokerDecoratorTests, 5 tests) — decorator applied on theChannel, external-CallInvoker, andAddresspaths; applied outsideChannelRecreatingCallInvoker; and not applied when unset.CallInvokerDecoratordefaults to null,SetCallInvokerDecorator(null)throws,ApplyCallInvokerDecoratoris identity without a decorator.Regression evidence
The extension regression tests were written first and observed failing on the base commit (
ab4be125) — 2 failing withExpected options.Channel to refer to ... but found <null>, 2 with theArgumentExceptionthrown fromDurableTaskWorkerBuilderExtensions.AzureBlobPayloads.cs:78. All pass after the fix.Verification
Notes
main, does not touch that branch.SetCallInvokerDecorator/ApplyCallInvokerDecoratorlive in the.Internalnamespace alongsideSetChannelRecreatorand carry the standard "not subject to the same compatibility standards" remarks.